iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
ChatGPT & Codex

AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發系列 第 11 篇

Day 11|新規則上線後,為什麼十年前的舊資料突然不能修改了?

  • 分享至 

  • xImage
  •  

前言/情境導入

Day 10 處理完「規則到底該由誰負責」之後,我原本以為流水號改版已經進入收尾。

新建資料會走新的流程,四碼十進位的規則也已經明確下來。照理說,接下來只要確認新資料正常新增就好。

結果真正出問題的,反而不是新資料,而是一筆舊資料。

資料庫裡原本就存在一些舊制流水號,例如:

001
029
0AF

新制則要求新建立的資料使用四碼十進位:

0001
0002
0003
...
0648

某天使用者打開一筆舊資料:

ProjectNo = P001
ItemNo    = 029
Remark    = 舊備註

他完全沒有修改 ItemNo,只是把備註改成:

補充 2026 年維護說明

按下儲存後,系統卻跳出:

流水號必須為四碼十進位。

這就很尷尬了。

029 的確不符合 2026 年的新建規則,但它是在舊制度下合法建立、而且已經存在多年的資料。使用者現在也根本沒有修改流水號。

真正的問題不是「舊資料格式錯了」,而是我把一條只應該約束新資料的規則,套到了所有年代的 UPDATE 上。

Day 10 問的是:

Who owns the rule?這條規則由誰負責?

到了 Day 11,還要再多問一題:

When does the rule apply?這條規則到底從什麼時候、對什麼操作開始生效?

同一條規則除了要知道誰負責,還要知道什麼時候適用。


核心技術解析

建立時合法,不代表今天還能用「新建規則」重新審判

這次最容易混淆的是兩句看起來很像的規則:

2026 年之後新增的流水號,必須是四碼十進位。

以及:

資料庫裡所有資料只要發生 UPDATE,流水號都必須是四碼十進位。

兩句只差幾個字,商業語意卻完全不同。

假設 029 在舊制度下原本就是合法資料,那麼今天使用者只修改 Remark 時,真正需要判斷的是「這個備註能不能修改」,而不是重新問:

如果今天要重新新增 029,它合不合法?

答案當然是不合法。

但這並不代表昨天合法存在的 029,今天就突然變成一筆不能修改的資料。

這也是 Legacy System 很常見的相容性問題:

Valid when created,不等於 Valid for new creation today。

相容性不是把舊資料全部放行,而是要精確區分:

  • 新資料不能再建立舊格式。
  • 舊資料可以保留原本合法的舊值。
  • 如果舊資料真的要修改流水號,就必須進入另一套明確的規則。

為什麼只改 Remark,Trigger 還是會檢查 ItemNo?

先看一個匿名化後的教學版本:

CREATE TABLE dbo.DocumentHeader
(
    DocumentId int IDENTITY(1,1) NOT NULL
        CONSTRAINT PK_DocumentHeader PRIMARY KEY,

    ProjectNo varchar(20) NOT NULL,
    ItemNo    varchar(4)  NOT NULL,
    Remark    nvarchar(200) NULL
);

-- 永久成立的不變量:
-- 同一案件不能出現相同流水號。
CREATE UNIQUE INDEX UX_DocumentHeader_ProjectNo_ItemNo
ON dbo.DocumentHeader(ProjectNo, ItemNo);

這裡保證的是資料庫字串值的唯一性;新舊格式是否具有相同業務語意,仍需另外確認。

假設在新規則上線前,資料庫裡早就存在:

INSERT INTO dbo.DocumentHeader
(
    ProjectNo,
    ItemNo,
    Remark
)
VALUES
(
    'P001',
    '029',
    N'十年前建立的資料'
);

後來為了保護四碼新制,新增了一個很直覺的 Trigger:

CREATE OR ALTER TRIGGER dbo.TR_DocumentHeader_ItemNoRule
ON dbo.DocumentHeader
AFTER INSERT, UPDATE
AS
BEGIN
    SET NOCOUNT ON;

    IF EXISTS
    (
        SELECT 1
        FROM inserted
        WHERE LEN(ItemNo) <> 4
           OR ItemNo LIKE '%[^0-9]%'
    )
    BEGIN
        THROW 51001, N'流水號必須為四碼十進位。', 1;
    END;
END;

問題就在 AFTER INSERT, UPDATE。

當我執行:

UPDATE dbo.DocumentHeader
SET Remark = N'補充 2026 年維護說明'
WHERE DocumentId = 1;

即使 ItemNo 完全沒變,這筆資料仍然會出現在 inserted。

Trigger 接著看到:

ItemNo = 029

再拿「四碼十進位」重新檢查一次,最後把整次 UPDATE 擋掉。

所以真正的 Bug 並不是:

029 是錯的。

而是:

我把「2026 年新增資料必須使用四碼」寫成了「任何年代的資料每次 UPDATE 都必須符合四碼」。

Backward Compatibility 也不是看到舊資料就全部跳過

另一個極端也不行。

如果只是為了讓 029 可以修改備註,就把所有 UPDATE 驗證拿掉,那麼使用者可能連流水號本身都能直接改掉。

例如:

029 → 030
029 → ABC

這就不是「保留歷史資料」,而是改變歷史資料的識別值了。

因此 Update 流程不能只問「這是不是舊資料」,而要再問:

這一次到底改了什麼?

SQL Server 在 UPDATE 時會提供:

  • deleted:更新前的資料。
  • inserted:更新後的資料。

所以如果要判斷 ItemNo 是否真的改變,應該比較前後值,而不是只看到 UPDATE 就重新套一次 Create Rule。

也不建議只靠:

IF UPDATE(ItemNo)

因為它比較接近「這個欄位有沒有出現在 UPDATE 動作中」,不等於「欄位值最後真的改變」。

Legacy System 裡,這個差異很重要。


程式碼實作

主解法:Create 與 Update 分流

這次我最後採用的方向,不是讓 Trigger 變得更聰明,而是先把 Create 與 Update 的語意拆開。

新建資料繼續嚴格遵守四碼新制:

CREATE OR ALTER PROCEDURE dbo.CreateDocument
    @ProjectNo varchar(20),
    @Remark nvarchar(200)
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @ItemNo varchar(4);

    -- 正式產號邏輯沿用前幾天處理過的流程。
    -- 這裡只示意新建資料最後必須得到四碼十進位。
    SET @ItemNo = '0648';

    IF LEN(@ItemNo) <> 4
       OR @ItemNo LIKE '%[^0-9]%'
    BEGIN
        THROW 51001, N'新文件流水號必須為四碼十進位。', 1;
    END;

    INSERT INTO dbo.DocumentHeader
    (
        ProjectNo,
        ItemNo,
        Remark
    )
    VALUES
    (
        @ProjectNo,
        @ItemNo,
        @Remark
    );
END;

修改既有資料時,則不要把 ItemNo 帶進一般的備註修改流程:

CREATE OR ALTER PROCEDURE dbo.UpdateDocumentRemark
    @DocumentId int,
    @Remark nvarchar(200)
AS
BEGIN
    SET NOCOUNT ON;

    UPDATE dbo.DocumentHeader
    SET Remark = @Remark
    WHERE DocumentId = @DocumentId;
END;

這個設計看起來很普通,但它其實把 Use Case 的能力限制得很清楚:

不是把 ItemNo 傳進來,再祈禱程式不要亂改;而是修改備註這個 Use Case 根本沒有修改 ItemNo 的能力。

Trigger 只守真正需要守的底線

資料庫仍然可能有舊 Client、批次程式,甚至直接 SQL 繞過 Stored Procedure。

因此如果 Trigger 要留下,我不再讓它對所有 UPDATE 重跑「四碼新建規則」,而是分成兩件事:

  1. 真正的新 INSERT 必須符合四碼十進位。
  2. 既有資料如果真的修改 ItemNo,一般流程直接拒絕。
CREATE OR ALTER TRIGGER dbo.TR_DocumentHeader_ItemNoRule
ON dbo.DocumentHeader
AFTER INSERT, UPDATE
AS
BEGIN
    SET NOCOUNT ON;

    -- 新增資料:此處只示意四碼十進位的「格式檢查」;
    -- 實際流水號範圍仍由正式 Create Rule 負責。
    IF EXISTS
    (
        SELECT 1
        FROM inserted AS i
        LEFT JOIN deleted AS d
            ON d.DocumentId = i.DocumentId
        WHERE d.DocumentId IS NULL
          AND
          (
              LEN(i.ItemNo) <> 4
              OR i.ItemNo LIKE '%[^0-9]%'
          )
    )
    BEGIN
        THROW 51001, N'新文件流水號必須為四碼十進位。', 1;
    END;

    -- 更新資料:只有 ItemNo 前後真的不同才攔截。
    IF EXISTS
    (
        SELECT 1
        FROM inserted AS i
        INNER JOIN deleted AS d
            ON d.DocumentId = i.DocumentId
        WHERE i.ItemNo <> d.ItemNo
    )
    BEGIN
        THROW 51002, N'既有文件不得透過一般修改流程變更流水號。', 1;
    END;
END;

這樣一來:

舊資料 029,只改 Remark
→ inserted.ItemNo = 029
→ deleted.ItemNo  = 029
→ ItemNo 沒有變
→ 允許

但如果是:

029 → 030

前後值不同,就會被擋下。

而新的:

029

如果企圖在新規則上線後直接 INSERT,也會被第一段檢查拒絕。

這才是我這次真正想要的相容性:

允許歷史存在,但不讓舊格式繼續被製造,也不讓一般流程任意改寫歷史識別值。

其他情境:RuleVersion、Soft-accept 與 Migration

不是每一套 Legacy System 都適合只靠 Create/Update 分流。

以下 RuleVersion 是另一種可選設計,不是前面主方案必須接續部署的步驟;如果採用這個方案,正式 Create 流程也必須固定寫入 RuleVersion = 2。

如果資料年代無法從格式可靠判斷,也可能需要額外加入 RuleVersion。但這個欄位不能永遠保持 Nullable,也不能變成讓新資料假裝自己是 Legacy 的逃生門。

比較安全的導入順序會是:

-- 1. 先允許 NULL,避免一加欄位就讓既有資料失敗。
ALTER TABLE dbo.DocumentHeader
ADD RuleVersion tinyint NULL;

-- 2. 先切換正式 Create 路徑:
--    所有新建資料固定寫入 RuleVersion = 2。

-- 3. 確認新的寫入路徑已不再產生 RuleVersion = NULL。

-- 4. 再 Backfill 當下剩餘的 NULL,視為既有 Legacy Data。
UPDATE dbo.DocumentHeader
SET RuleVersion = 1
WHERE RuleVersion IS NULL;

-- 5. 驗證資料中已經沒有 RuleVersion = NULL。
IF EXISTS
(
    SELECT 1
    FROM dbo.DocumentHeader
    WHERE RuleVersion IS NULL
)
BEGIN
    THROW 51003, N'RuleVersion 尚有未分類資料,暫停收斂。', 1;
END;

-- 6. 完成分類後,再收斂成 NOT NULL。
ALTER TABLE dbo.DocumentHeader
ALTER COLUMN RuleVersion tinyint NOT NULL;

-- 7. 最後才建立版本規則。
ALTER TABLE dbo.DocumentHeader
ADD CONSTRAINT CK_DocumentHeader_ItemNoByVersion
CHECK
(
    RuleVersion = 1
    OR
    (
        RuleVersion = 2
        AND LEN(ItemNo) = 4
        AND ItemNo NOT LIKE '%[^0-9]%'
    )
);

這個順序的關鍵是:先堵住新的 NULL,再清掉歷史 NULL。 這樣 Backfill 執行時,才不會把切換期間剛新增、但尚未寫入版本值的資料誤標成 Legacy。換句話說,連 Migration 本身也有時間邊界。

最後仍要收斂成 NOT NULL,因為 CHECK Constraint 對 NULL 所形成的 UNKNOWN 判斷可能不會像直覺上那樣把資料拒絕掉。

而且即使加了 RuleVersion,一般新建入口也不能自由指定:

RuleVersion = 1

UI、API、Stored Procedure,甚至 Direct SQL 的權限都要一起考慮;否則版本欄位反而會變成繞過新規則的後門。

CHECK Constraint 能驗證一筆資料現在的狀態,卻不能證明 RuleVersion = 1 真的是十年前留下來的歷史資料。

另一種做法是 Soft-accept:

Read old, write new.

舊資料照原樣讀取與修改非識別欄位,但所有新資料只寫新格式。

如果未來確認舊編號沒有外部引用、也能建立可靠的對照關係,才考慮做 Migration。這些都是可能的策略,但在這次案例裡,我不需要一次把問題擴大成全面資料搬遷。

Regression Test:修好舊資料,也不能順手拆掉新規則

這次修完後,我不只測:

029 現在可以改備註了嗎?

因為最危險的修法,就是救回舊資料的同時,也讓新系統重新可以建立三碼資料。

我最後至少會驗證這些情境:

測試情境 操作 預期結果
新制正常新增 走正式 Create 流程 成功,得到四碼十進位
新資料嘗試建立 029 直接 INSERT 或繞過 UI 拒絕
新資料嘗試建立 0AF 直接 INSERT 或繞過 UI 拒絕
舊資料 029 只改 Remark 不碰 ItemNo 成功,ItemNo 保持 029
舊資料 0AF 只改 Remark 不碰 ItemNo 若原本為合法歷史資料,應成功
舊資料直接修改 ItemNo 029 → 030 一般流程拒絕
同案件重複 ItemNo 建立重複鍵值 Unique Index 拒絕
批次修改 Remark 一次 UPDATE 多筆 全部依集合邏輯正確處理
批次中包含非法改號 同一個 UPDATE 涵蓋多筆,其中至少一筆 ItemNo 被修改 整個 Statement 被拒絕,不留下部分成功

其中 Legacy 測試資料的準備方式也要特別注意。

下面這種 fixture:

DECLARE @LegacyId int;

INSERT INTO dbo.DocumentHeader
(
    ProjectNo,
    ItemNo,
    Remark
)
VALUES
(
    'TEST-P001',
    '029',
    N'Legacy'
);

SET @LegacyId = SCOPE_IDENTITY();

只能用來模擬「新規則啟用以前就已經存在的資料」。

實際測試時,這筆資料應該在新規則部署前建立,或由隔離的測試資料初始化腳本準備;不能在 Production 新規則已啟用後,再透過正式 Create 流程建立 029。

因為連測試資料怎麼被建立,本身都有時間語意。

準備好 Legacy fixture 並部署新規則後,再測:

UPDATE dbo.DocumentHeader
SET Remark = N'Legacy remark updated'
WHERE DocumentId = @LegacyId;

SELECT
    DocumentId,
    ItemNo,
    Remark
FROM dbo.DocumentHeader
WHERE DocumentId = @LegacyId;

預期結果:

ItemNo = 029
Remark = Legacy remark updated

接著再驗證非法改號:

UPDATE dbo.DocumentHeader
SET ItemNo = '030'
WHERE DocumentId = @LegacyId;

這一步應該失敗。

最後再確認新資料不能重新製造舊格式:

INSERT INTO dbo.DocumentHeader
(
    ProjectNo,
    ItemNo,
    Remark
)
VALUES
(
    'TEST-P002',
    '029',
    N'New legacy-shaped data'
);

這一步也應該失敗。

這次我怎麼讓 AI 幫忙做 Regression

這一段我反而不會請 AI 直接重寫 Trigger。

我比較需要它幫我找出「我可能漏測了什麼」。

以下是 Legacy ERP 從三碼舊制改為四碼新制後的 SQL / Delphi 修改。

我要驗證的不是「程式能編譯」,而是 backward compatibility。

已知規則:
1. 新資料只能建立四碼十進位 ItemNo。
2. 舊三碼或舊混碼資料可能仍合法存在。
3. 修改 Remark 等非 ItemNo 欄位時,不應重新套用新建格式規則。
4. ItemNo 若真的改變,必須進入專用流程或拒絕。
5. Trigger 必須支援 multi-row INSERT / UPDATE。

請建立 Regression Test Matrix,涵蓋:
- 新資料正常 INSERT
- 新資料三碼或混碼應被拒絕
- 舊資料只修改非 ItemNo 欄位
- 舊資料嘗試修改 ItemNo
- 同案件重複 ItemNo
- Multi-row UPDATE
- Multi-row UPDATE 中只有部分資料修改 ItemNo,確認整個 Statement 的結果
- Direct SQL bypass UI
- 舊 Client 與新版 Client 共存
- Rollback

每個案例請列出:
Precondition、Action、Expected database result、Expected UI result、
Possible false positive、Rollback step。

不要只測 Happy Path,也不要先假設目前修改已經正確。

AI 在這裡最有價值的地方,不是替我宣布「修好了」。

而是幫我多想幾種方式,證明它可能還沒好。


今日小結

這次最後並不是靠一條更複雜的四碼驗證解決問題。

真正需要修改的,是我對「合法資料」的理解。

029 如果是在舊制度下合法建立的資料,那麼今天使用者只修改備註時,我不能因為新建規則改成四碼,就突然把它判成非法資料。

但這也不代表新系統可以繼續建立 029。

Day 10 處理的是「這條規則由誰負責」;Day 11 則往前多追了一步:

這條規則到底從什麼時候開始有權管資料?

這也是維護 Legacy System 很容易被忽略的一層。

AI 可以幫我搜尋所有 Trigger、Stored Procedure、Delphi 事件與 SQL 條件,也可以幫我整理哪些地方在 INSERT、哪些地方在 UPDATE。

但它不會憑空知道 029 在十年前是不是一筆合法建立、已經核准,甚至早就被其他系統引用的正式資料。

這個邊界,最後還是必須由維護工程師確認。

💡 今日金句:新規則的責任,不是改寫歷史,而是確保從今天開始不要再製造新的歷史問題。



上一篇
Day 10|這個規則到底該寫在哪?UI、Stored Procedure 還是 Trigger?
系列文
AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發 共 11 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言